昨天先把 RDB 的資料模型定下來,今天就直接在 code/db/rdb.go 補 SaveRDB 和 LoadRDB,順便把 Server 啟動時的資料恢復流程接起來。
SaveRDB保存時先用讀鎖(RLock)拷貝目前 DB map 的快照。為了避免寫到一半當機導致舊檔也壞掉,我採用原子更名策略:先寫到臨時檔 dump.rdb.tmp,完成後再用 os.Rename 替換舊檔。
這邊我第一版直接存原檔,結果寫一半當機檔案直接壞掉,後來才加上臨時檔跟 os.Rename 原子替換,這真的是血淚教訓。
func (db *DB) SaveRDB(filename string) error {
db.mu.RLock()
snapshot := make(map[string]rdbEntry, len(db.data))
for k, v := range db.data {
if v.isExpired() { continue }
// 將複雜資料結構扁平化為 Gob 可編碼型態
var serializableVal interface{}
switch v.dataType {
case TypeString:
serializableVal = v.val.([]byte)
case TypeList:
l := v.val.(*list.List)
// ... 收集為 [][]byte ...
}
snapshot[k] = rdbEntry{
DataType: v.dataType,
Val: serializableVal,
ExpireAt: v.expireAt,
}
}
db.mu.RUnlock()
// 原子覆蓋寫入
tempFilename := filename + ".tmp"
file, _ := os.OpenFile(tempFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
defer file.Close()
encoder := gob.NewEncoder(file)
_ = encoder.Encode(snapshot)
_ = file.Sync()
_ = file.Close()
return os.Rename(tempFilename, filename)
}
LoadRDB載入時,把 Gob 編碼的檔案讀回來,再還原成 Go 裡真正要用的雙向鏈結串列或跳躍表結構:
func (db *DB) LoadRDB(filename string) error {
file, err := os.Open(filename)
if err != nil { return err }
defer file.Close()
var snapshot map[string]rdbEntry
decoder := gob.NewDecoder(file)
_ = decoder.Decode(&snapshot)
db.mu.Lock()
defer db.mu.Unlock()
db.data = make(map[string]*entry)
for k, v := range snapshot {
if !v.ExpireAt.IsZero() && time.Now().After(v.ExpireAt) { continue } // 排除已過期
var val interface{}
switch v.DataType {
case TypeList:
l := list.New()
// 還原為雙向鏈結
// ...
val = l
}
db.data[k] = &entry{
dataType: v.DataType,
val: val,
expireAt: v.ExpireAt,
}
}
return nil
}
Redis Clone 啟動時,要先決定該讀哪一份持久化資料。這裡我照 Redis 的方向處理:
我們在 code/server/server.go 的 Start() 階段實作了此邏輯:
func (s *Server) Start() error {
// 1. 優先嘗試加載 AOF
aofLogger, err := db.NewAofLogger("appendonly.aof", s.dbEngine)
if err == nil {
s.aofLogger = aofLogger
dummyClient := command.NewClient(nil, false)
// 載入與重播
_ = s.aofLogger.LoadAndReplay(func(val resp.Value) {
s.dispatcher.Dispatch(dummyClient, val)
})
} else {
// 2. 若無 AOF,嘗試加載 RDB 快照
err := s.dbEngine.LoadRDB("dump.rdb")
if err == nil {
log.Println("成功從 RDB 快照載入恢復資料")
}
}
// 3. 開始 TCP 監聽
// ...
}
這個順序的理由很簡單:AOF 通常記得比較細,所以有 AOF 就先 replay;沒有 AOF 時,再退回讀 RDB 快照。
來驗證一下我們辛苦寫的持久化恢復功能有沒有作用。先寫點資料然後重啟 server:
# 終端機 1: 寫入資料
$ redis-cli
> SET user "sky"
# 預期回覆:OK
> SHUTDOWN
接著把 server 重新跑起來,觀察 log:
# 終端機 2: Server 啟動畫面
$ go run main.go
2026/08/13 22:45:10 [INFO] 找到 appendonly.aof,開始載入...
2026/08/13 22:45:10 [INFO] 成功從 AOF 重播 3 條命令
2026/08/13 22:45:10 [INFO] Server started on :6379
最後回到 cli 檢查剛才的 user 還在不在:
$ redis-cli
> GET user
# 預期回覆:"sky"
這就代表資料平安活過重啟了!
今天把 RDB 的保存、載入,以及啟動時 AOF/RDB 的判斷順序都串起來了。
明天換個方向,來弄記憶體淘汰機制 LRU。記憶體爆掉可不是開玩笑的。